import { isNil } from 'lodash-es'
import { reactive, readonly, Ref, shallowRef } from 'vue'
import { NavigationGuardNext, RouteMeta } from 'vue-router'
import { clearCaches, getCache, setCache } from '../cache'
import { getCookie, removeCookie, setCookie } from '../cookie'
import { clearObject, Data, httpConfig, login } from '../index'
import { serverTime } from '../server'
import { MOCK_SESSION_EXPIRE_KEY, removeStorage } from '../storage'
import { base64Decode } from '../util'
import { LoginConfig, loginConfig } from './config'


export * from './config'
export * from './useRemember'


export class LoginError extends Error {
  constructor(message: string) {
    super(message)
  }
}


export class TokenNotExistError extends LoginError {
  constructor(message: string) {
    super(message)
  }
}

export class RefreshTokenExpireError extends LoginError {
  constructor() {
    super('刷新令牌已过期')
  }
}

export class RefreshTokenError extends LoginError {
}

export function getRemember(defaultValue = false): boolean {
  return getCache('$r$', defaultValue)
}

class TokenRefreshQueue {
  private isRefreshing = false;
  private refreshPromise: Promise<any> | null = null;

  async waitForRefresh(): Promise<void> {
    if (this.isRefreshing && this.refreshPromise) {
      return this.refreshPromise
    } else {
      return Promise.resolve()
    }
  }

  startRefresh(refreshPromise: Promise<any>): void {
    this.isRefreshing = true;
    this.refreshPromise = refreshPromise
  }

  finishRefresh(): void {
    this.isRefreshing = false
    this.refreshPromise = null
  }

  isRefreshInProgress(): boolean {
    return this.isRefreshing
  }
}

class Token {
  private static refreshQueue = new TokenRefreshQueue();
  readonly idToken: any = reactive({})
  readonly accessToken: any = reactive({})
  readonly refreshToken: any = reactive({})

  constructor() {
    this.parseAll(this.getAccessToken(), this.getRefreshToken(), this.getIdToken())
  }

  withCredentials() {
    return httpConfig.axios.withCredentials
  }

  async set(accessToken: string | null, refreshToken: string, idToken: string): Promise<any> {
    this.clear()
    if (this.withCredentials()) {
      accessToken = null
    } else {
      setCookie(loginConfig.tokenName, accessToken)
    }
    setCookie('$iat$', serverTime.value / 1000)

    this.parseAll(accessToken, refreshToken, idToken);

    if (refreshToken) {
      setCache(loginConfig.refreshTokenName, refreshToken, getRemember() ? this.calcExpire(this.refreshToken) : 0, { global: true });
    }

    if (idToken) {
      setCache(loginConfig.idTokenName, idToken, 0, { global: true })
      const id = this.idToken[loginConfig.tokenIdField]
      isNil(id) || setCookie(loginConfig.tokenIdName, id)
    }
    await this.afterToken()
  }

  private parseAll(accessToken: string | null, refreshToken: string | null, idToken: string | null): void {
    this.clearToken()
    idToken && Object.assign(this.idToken, this.parse(idToken))
    refreshToken && Object.assign(this.refreshToken, this.parse(refreshToken))
    accessToken && Object.assign(this.accessToken, this.parse(accessToken))
  }

  private parse(tk: string): any {
    try {
      const arr = tk.split('.')
      const payload = base64Decode(arr.length == 4 ? arr[2] : arr[1])
      payload.iat || (payload.iat = Number(getCookie('iat')))
      return payload
    } catch {
      this.clear(true)
    }
  }

  clearToken(onlyClearAt?: boolean): void {
    clearObject(this.accessToken)
    clearObject(this.idToken)
    if (!onlyClearAt) {
      clearObject(this.refreshToken)
    }
  }

  afterToken(): Promise<any> | void {
    return loginConfig.afterToken(this.accessToken, this.refreshToken, this.idToken)
  }

  getAccessToken(): string | null {
    return getCookie(loginConfig.tokenName)
  }

  getRefreshToken(): string | null {
    return getCache(loginConfig.refreshTokenName, null, { throw: true, remove: false, global: true })
  }

  getIdToken(): string | null {
    return getCache(loginConfig.idTokenName, null, { throw: true, remove: false, global: true })
  }

  getTokenId(): string | null {
    return getCookie(loginConfig.tokenIdName)
  }

  private calcExpire({ exp, iat }: Data): number {
    return exp - iat
  }

  private isExpire({ exp }: Data, offset = 0): boolean {
    try {
      return exp < ((serverTime.value / 1000) + offset)
    } catch {
      return false
    }
  }

  hasAccessToken(): boolean {
    return Boolean(this.getAccessToken())
  }

  hasRefreshToken(): boolean {
    return Boolean(this.getRefreshToken())
  }

  hasIdToken(): boolean {
    return Boolean(this.getIdToken())
  }

  private hasToken(): boolean {
    try {
      return this.hasIdToken() || this.hasRefreshToken() || this.hasAccessToken()
    } catch {
      return false
    }
  }

  async refresh(reload?: boolean): Promise<any> {
    if (Token.refreshQueue.isRefreshInProgress()) {
      return Token.refreshQueue.waitForRefresh()
    }
    try {
      const refreshPromise = (async () => {
        const tk: any = await loginConfig.getServerRefreshToken(this.getRefreshToken(), { cancelTokenName: 'rt', ignoreToken: true, ignoreError: true, refresh: true })
        if (tk) {
          await this.set(tk.access_token, tk.refresh_token, tk.id_token)
        } else {
          throw new RefreshTokenError('服务器获取刷新令牌设置异常')
        }
        reload && location.reload()
      })()
      Token.refreshQueue.startRefresh(refreshPromise)
      await refreshPromise
      Token.refreshQueue.finishRefresh()
    } catch (e: any) {
      Token.refreshQueue.finishRefresh()
      throw new RefreshTokenError(e?.message)
    }
  }

  isRefreshInProgress(): boolean {
    return Token.refreshQueue.isRefreshInProgress();
  }

  waitForRefresh(): Promise<void> {
    return Token.refreshQueue.waitForRefresh();
  }

  async check(forceRefreshToken?: boolean, reload?: boolean): Promise<boolean> {
    if (!this.hasToken()) {
      throw new TokenNotExistError('令牌不存在')
    }
    const it = this.idToken
    const rt = this.refreshToken
    return await new Promise((resolve: any, reject): void => {
      if (!forceRefreshToken) {
        if (this.hasIdToken()) {
          if (!this.isExpire(it, loginConfig.tokenExpOffset)) {
            return resolve(true)
          }
        }
      }
      if (this.hasRefreshToken() && this.isExpire(rt)) {
        reject(new RefreshTokenExpireError())
      } else {
        this.refresh(reload).then(() => resolve(true)).catch(reject)
      }
    })
  }

  clear(reload?: boolean): void {
    clearCaches()
    removeCookie(MOCK_SESSION_EXPIRE_KEY)
    removeCookie(loginConfig.tokenIdName)
    this.withCredentials() || removeCookie(loginConfig.tokenName)
    removeCookie(loginConfig.refreshTokenName)
    removeStorage(loginConfig.refreshTokenName, true)
    removeStorage(loginConfig.idTokenName, true)
    removeCookie('$iat$')
    this.clearToken()
    reload && location.reload()
  }
}

export default class Login {
  token: Token | any
  userInfo: Data | any
  accessInfo: Data | any
  refreshInfo: Data | any

  constructor(config?: LoginConfig) {
    if (config) {
      for (const key in config) {
        // @ts-ignore
        loginConfig[key] = config[key]
      }
    }
  }

  init(): void {
    this.token = new Token()
    this.userInfo = readonly(this.token.idToken)
    this.accessInfo = readonly(this.token.accessInfo)
    this.refreshInfo = readonly(this.token.refreshInfo)
  }

  setToken(at: string, rt: string, it: string): Promise<any> {
    return this.token.set(at, rt, it)
  }

  async logout(options?: any): Promise<any> {
    this.isLogin().value && (await loginConfig.beforeLogout(options))
    this.token.clear()
    await loginConfig.afterLogout(options)
  }

  isLogin(): Ref<boolean> {
    return shallowRef(loginConfig.isLogin())
  }

  getToken(): string | null {
    return this.token.getAccessToken()
  }

  async loginCheck(meta: RouteMeta, next: NavigationGuardNext): Promise<void> {
    if (this.isLogin().value) {
      if (await this.tokenCheck(false, false)) {
        if (await loginConfig.afterLoginCheck(meta, next)) {
          next()
        }
      }
    } else {
      try {
        await this.token.check(false, false)
        next()
      } catch {
        await loginConfig.beforeLoginCheck()
      }
    }
  }

  async tokenCheck(forceRt?: boolean, reload?: boolean): Promise<boolean> {
    try {
      return await this.token.check(forceRt, reload)
    } catch (e) {
      // @ts-ignore
      return await loginConfig.tokenCheckError(e)
    }
  }

  clear(): void {
    this.token.clear(true)
  }
}

export function getTokenId(): string | null {
  return login.token.getTokenId()
}
